Devin.KR
로그인

파이썬 문자열 다루기 - f-string 포매팅과 한글 인코딩 (파이썬 초급 6단원)

개발자 조회 1

이 단원에서 배우는 것

5단원까지 결과를 print(title, stock)로 대충 찍어 왔다. 값을 확인하는 데는 충분하지만 손님에게 보여 줄 영수증은 못 된다. 금액에 천 단위 콤마가 없고 열이 안 맞는다. 이번 단원은 그 출력을 제대로 만든다. 그리고 한국에서 파이썬을 쓰면 반드시 한 번은 만나는 한글 깨짐의 원인을 여기서 끝낸다. 7단원에서 파일을 열 때 이 지식이 곧바로 필요하다.

  • f-string 으로 값을 끼워 넣고 자릿수·정렬·천 단위 구분을 지정한다.
  • split·strip·join으로 문자열을 자르고 붙인다.
  • 문자열과 바이트열의 차이를 알고 인코딩 오류를 읽는다.

왜 필요한가

프로그램 안에서는 숫자가 28000이면 그만이지만, 그 값이 프로그램 밖으로 나가는 순간 전부 문자열이 된다. 화면, 로그, 파일, 네트워크, 데이터베이스 어디든 마찬가지다. 문자열 처리는 프로그램의 모든 경계에서 벌어지는 일이라 양이 압도적으로 많다.

그리고 그 경계에서 한글이 깨진다. 원인은 하나다. 컴퓨터는 글자를 저장할 수 없고 숫자만 저장할 수 있어서, "이 글자를 어떤 숫자로 적을 것인가"라는 약속이 필요하다. 그 약속이 인코딩이다. 저장할 때 쓴 약속과 읽을 때 쓴 약속이 다르면 글자가 깨진다. 우리가 흔히 만나는 UnicodeDecodeErrorìŒ 같은 글자는 전부 약속이 어긋났다는 신호이지 파이썬의 결함이 아니다. 이걸 알고 나면 오류 메시지만 보고 원인을 짚을 수 있다.

문법과 예제

f-string

문자열 앞에 f를 붙이면 중괄호 안의 값이 그 자리에 들어간다. 파이썬 3.6부터 쓸 수 있고, 지금은 사실상 표준이다.

title = "파이썬 개론"
price = 28000
qty = 2

print(f"{title} {price}원 x {qty}권 = {price * qty}원")
# 파이썬 개론 28000원 x 2권 = 56000원

중괄호 안에는 변수뿐 아니라 계산식과 함수 호출도 들어간다. 위 예의 price * qty가 그렇다.

이제 서식이다. 콜론 뒤에 형식을 적는다. 금액 출력은 , 하나로 끝난다.

print(f"{price:,}원")            # 28,000원
print(f"{price * qty:,}원")      # 56,000원
print(f"{0.153:.1%}")            # 15.3%      ← 백분율로 바꿔 준다
print(f"{3.14159:.2f}")          # 3.14       ← 소수점 둘째 자리까지 반올림

정렬은 <(왼쪽) >(오른쪽) ^(가운데)에 폭을 붙인다.

print(f"[{title:<12}][{price:>8,}]")
# [파이썬 개론      ][  28,000]

디버깅용 표기도 알아 두면 편하다. 등호를 붙이면 변수 이름까지 같이 찍힌다. 3.8부터 된다.

print(f"{price=}")     # price=28000

이제 5단원의 함수와 합쳐 영수증을 만든다. 이번 단원의 대표 예제다.

def print_receipt(items):
    """items 는 (제목, 정가, 수량) 튜플의 리스트다."""
    print("=" * 34)
    print(f"{'서점 영수증':^30}")
    print("=" * 34)

    total = 0
    for title, price, qty in items:
        amount = price * qty
        total += amount
        print(f"{title:<16}{qty:>3}권{amount:>10,}원")

    print("-" * 34)
    print(f"{'합계':<16}{'':>4}{total:>10,}원")
    print(f"{'부가세 포함':<16}{'':>4}{round(total * 1.1):>10,}원")


cart = [
    ("파이썬 개론", 28000, 2),
    ("리팩터링", 33000, 1),
    ("SQL 첫걸음", 22000, 3),
]
print_receipt(cart)
==================================
            서점 영수증            
==================================
파이썬 개론            2권    56,000원
리팩터링              1권    33,000원
SQL 첫걸음           3권    66,000원
----------------------------------
합계                     155,000원
부가세 포함                 170,500원

"=" * 34는 문자열에 정수를 곱해 반복하는 문법이다. 2단원에서 input() 결과를 곱해 사고가 났던 그 동작이고, 여기서는 의도적으로 쓴다.

그런데 자세히 보면 SQL 첫걸음 줄만 수량 칸이 왼쪽으로 밀려 있다. :<16으로 폭을 똑같이 줬는데도 그렇다. 버그가 아니라 f-string 이 세는 단위 때문이고, 아래 "실무에서 자주 틀리는 것" 세 번째에서 원인과 해결책을 본다.

자르고 붙이기

7단원에서 CSV 파일을 다룰 준비다. 파일에서 읽은 한 줄은 대개 이런 모양이다.

line = "  파이썬 개론 , 28000 , 3  "

print(line.strip())                 # 앞뒤 공백 제거
print(line.split(","))              # ['  파이썬 개론 ', ' 28000 ', ' 3  ']

split()만 하면 각 조각에 공백이 남는다. 그래서 자른 뒤 각각 strip()을 거는 것이 한 세트다.

parts = []
for piece in line.split(","):
    parts.append(piece.strip())

title = parts[0]
price = int(parts[1])      # 2단원: 파일에서 온 값은 문자열이다
qty = int(parts[2])
print(title, price, qty)   # 파이썬 개론 28000 3

반대로 붙일 때는 join()을 쓴다. 구분자에 점을 찍고 리스트를 넘긴다는 순서가 처음엔 어색하다.

titles = ["파이썬 개론", "리팩터링", "SQL 첫걸음"]
print(", ".join(titles))    # 파이썬 개론, 리팩터링, SQL 첫걸음
print("\n".join(titles))    # 줄바꿈으로 이어 붙인다

join()에 넘기는 리스트의 원소는 전부 문자열이어야 한다. 숫자가 하나라도 섞이면 TypeError: sequence item 1: expected str instance, int found처럼 몇 번째 원소가 문제인지까지 알려 주며 죽는다. 숫자를 이어 붙일 때는 str()로 먼저 바꾼다.

자주 쓰는 나머지 메서드들이다.

s = "파이썬 개론"
print(s.replace("파이썬", "자바"))   # 자바 개론
print(s)                             # 파이썬 개론   ← 원본은 안 바뀐다

print(s.startswith("파이썬"))        # True
print("개론" in s)                   # True
print(s.find("개론"))                # 4    ← 없으면 -1
print(s.index("없음"))               # ValueError: substring not found

문자열은 튜플처럼 바꿀 수 없는 값이다. replace(), upper(), strip()은 전부 원본을 놔두고 새 문자열을 돌려준다. 3단원에서 본 list.sort()와 정반대 성격이므로 헷갈리지 않게 한다. 리스트 메서드는 원본을 바꾸고 None을 주며, 문자열 메서드는 원본을 놔두고 새 값을 준다.

문자열과 바이트열

파이썬 3에는 글자를 다루는 str과 바이트를 다루는 bytes가 따로 있다. 둘을 오가는 두 동작만 알면 된다.

s = "파이썬"

b = s.encode("utf-8")      # str -> bytes  (인코딩: 내보내기)
print(b)                   # b'\xed\x8c\x8c\xec\x9d\xb4\xec\x8d\xac'
print(type(b))             # <class 'bytes'>

print(b.decode("utf-8"))   # bytes -> str  (디코딩: 읽어 들이기)  파이썬

여기서 한국 실무의 핵심이 나온다. 한글 한 글자는 UTF-8에서 3바이트, CP949(euc-kr 계열)에서 2바이트다.

s = "파이썬 개론"
print(len(s))                     # 6    ← 글자 수 (공백 포함)
print(len(s.encode("utf-8")))     # 16   ← UTF-8 바이트 수
print(len(s.encode("cp949")))     # 11   ← CP949 바이트 수

이 차이가 그냥 상식이 아니라 실무 문제인 이유가 있다. 데이터베이스의 varchar(100)이 글자 수인지 바이트 수인지는 DB와 설정마다 다르고, 바이트 기준인 곳에 UTF-8 한글을 넣으면 33글자에서 잘린다. 길이 제한을 검사할 때 len()만 보면 안 되는 이유다.

인코딩이 어긋나면 다음 둘 중 하나가 일어난다. 운이 좋으면 예외로 죽고, 나쁘면 이상한 글자가 조용히 저장된다.

"파이썬".encode("utf-8").decode("cp949")
# UnicodeDecodeError: 'cp949' codec can't decode byte 0xed in position 0:
# illegal multibyte sequence

"파이썬".encode("cp949").decode("utf-8")
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc6 in position 0:
# invalid continuation byte

오류 메시지를 읽는 법은 간단하다. 앞에 나온 코덱 이름이 "읽으려고 시도한 약속"이다. 'cp949' codec can't decode라면 파이썬은 CP949로 읽으려 했는데 파일이 실은 UTF-8이라는 뜻이다.

실무에서 자주 틀리는 것

1. 파일 인코딩을 지정하지 않는다

7단원에서 자세히 다루지만 원인은 여기 있다. 지금까지 나온 모든 버전에서 open()에 인코딩을 안 주면 운영체제 기본값을 쓴다. 한국어 윈도우는 CP949, 맥과 리눅스는 UTF-8이다. 그래서 맥에서 만든 CSV를 윈도우 동료가 열면 UnicodeDecodeError가 나고, 반대도 마찬가지다. 코드는 똑같은데 환경만 다르다.

open("sales.csv")                        # 환경에 따라 결과가 달라진다
open("sales.csv", encoding="utf-8")      # 언제 어디서 실행해도 같다

파이썬 3.15부터는 기본값이 UTF-8로 바뀔 예정이고, 지금도 PYTHONUTF8=1 환경 변수로 미리 켤 수 있다. 하지만 어느 버전에서 돌든 encoding=은 항상 직접 적는다가 정답이다. 한 글자 더 쓰고 환경 차이를 없앤다.

덧붙여 엑셀은 UTF-8 CSV를 열 때 파일 맨 앞의 BOM 표시를 보고 판단한다. 엑셀에서 열 파일을 만든다면 encoding="utf-8-sig"를 쓴다. 이 한 가지로 "엑셀에서 열면 한글이 깨져요" 문의가 사라진다.

2. 반복문 안에서 + 로 문자열을 이어 붙인다

report = ""
for title in titles:
    report += title + "\n"     # 돌 때마다 문자열을 통째로 새로 만든다

문자열은 바꿀 수 없는 값이라, +=는 기존 문자열을 고치는 게 아니라 합친 결과로 새 문자열을 만드는 일이다. 100줄이면 100번, 10만 줄이면 10만 번 새로 만든다. 리스트에 모았다가 마지막에 join()으로 한 번에 붙이면 이 낭비가 없다.

lines = []
for title in titles:
    lines.append(title)
report = "\n".join(lines)

3. 한글에 :<12 를 걸고 열이 맞기를 기대한다

print(f"{'파이썬 개론':<12}|")
print(f"{'Python':<12}|")
파이썬 개론      |
Python      |

둘 다 12칸으로 채웠는데 끝의 |가 안 맞는다. 파이썬이 세는 것은 글자 수인데 터미널에서 한글 한 글자는 영문 두 칸을 차지하기 때문이다. 앞의 영수증에서 SQL 첫걸음 줄만 밀린 것도 같은 이유다. 이 제목은 파이썬이 세기에 7글자지만 화면에서는 10칸을 차지한다. 한글만 있는 목록은 우연히 맞아 보이고, 영문이 섞이는 순간 어긋난다. 정확히 맞추려면 표준 라이브러리 unicodedata로 표시 폭을 직접 계산해야 한다.

import unicodedata

def display_width(s):
    """터미널에서 차지하는 칸 수를 센다."""
    width = 0
    for ch in s:
        width += 2 if unicodedata.east_asian_width(ch) in "WF" else 1
    return width

def pad(s, width):
    return s + " " * (width - display_width(s))

print(pad("파이썬 개론", 20) + "|")
print(pad("Python", 20) + "|")     # 이제 맞는다

실무에서는 열 맞춤이 꼭 필요한지부터 따진다. 화면 보고서가 아니라 CSV나 JSON으로 내보낼 것이면 이 문제 자체가 없다.

4. f-string 안의 따옴표와 역슬래시

파이썬 3.11까지는 f-string 중괄호 안에서 바깥과 같은 따옴표를 쓸 수 없고 역슬래시도 넣을 수 없다.

print(f"{d["title"]}")      # 3.11 이하: SyntaxError
print(f"{"\n".join(items)}")  # 3.11 이하: SyntaxError

3.12부터 둘 다 허용된다. 문제는 내 노트북은 3.12인데 서버는 3.11인 경우다. 로컬에서 잘 돌던 코드가 배포 후 문법 오류로 죽는다. 1단원에서 실행 환경이 프로그램의 일부라고 한 말이 이런 뜻이다. 어느 버전에서나 안전한 방법은 따옴표를 바꿔 쓰거나 미리 변수에 담아 두는 것이다.

print(f"{d['title']}")       # 안쪽에 작은따옴표: 모든 버전에서 된다

sep = "\n"
print(f"{sep.join(items)}")  # 역슬래시를 밖으로 뺀다

스스로 확인하기

  1. 정가 33000원짜리 책 3권의 합계를 합계: 99,000원 형태로 출력하는 f-string 한 줄을 쓴다. (콜론 뒤에 금액이 오른쪽 정렬로 10칸)
  2. "파이썬 개론"len()은 6인데 UTF-8로 인코딩하면 16바이트다. 왜 18이 아닌가?
  3. 다음 코드가 파이썬 개론을 그대로 출력하는 이유는? 원하는 결과를 얻으려면?
    title = "파이썬 개론"
    title.replace("개론", "실전")
    print(title)

정답

  1. print(f"합계:{33000 * 3:>10,}원")이다. >10이 오른쪽 정렬 10칸, ,가 천 단위 구분이며 순서는 >10,다. ,>10으로 순서를 바꿔 쓰면 오류가 나지는 않지만 뜻이 완전히 달라진다. 쉼표가 채움 문자로 해석되어 ,,,,,99000이 나온다.
  2. 공백 한 칸이 섞여 있기 때문이다. 한글 5글자가 3바이트씩 15바이트, 공백 한 칸이 1바이트여서 16바이트다. UTF-8은 글자마다 바이트 수가 다른 방식이라 영문·숫자는 1바이트, 한글은 3바이트를 쓴다. "글자 수 x 3"으로 계산하면 틀린다.
  3. 문자열은 바꿀 수 없는 값이라 replace()는 원본을 고치지 않고 새 문자열을 돌려준다. 돌려받은 값을 아무 데도 담지 않았으니 그대로 버려졌다. title = title.replace("개론", "실전")처럼 결과를 다시 대입해야 한다. strip(), upper(), lower()도 전부 같다.

다음 단원에서는 지금까지 코드 안에 직접 적어 두던 재고와 판매 데이터를 파일로 옮긴다. 프로그램을 껐다 켜도 데이터가 남게 되고, 여기서 이번 단원의 인코딩 지식이 바로 필요해진다.